#!/usr/bin/env python
# splatd.py vi:ts=4:sw=4:expandtab:
#
# Authors:
#       Will Barton <wbb4@opendarwin.org>
#       Landon Fuller <landonf@threerings.net
#
# Copyright (c) 2005-2006 Three Rings Design, Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright owner nor the names of contributors
#    may be used to endorse or promote products derived from this software
#    without specific prior written permission.
# 
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

import getopt, sys, ldap, time
import os, signal, logging, random
from twisted.internet import reactor, defer
import ZConfig

import splat
from splat import ldaputils, daemon, plugin

class FatalError(splat.SplatError):
    """
    Unrecoverable daemon error.
    """
    pass

class Main(object):
    """
    Implements Splatd's Main Runloop
    """
    # Initial reconnection delay.
    initialDelay = 10
    # Maximum amount to delay reconnection (5 minutes)
    maxDelay = 300
    # Standard deviation to avoid stampeding clients
    jitter = 10

    def __init__(self):
        # Backoff delay
        self.delay = self.initialDelay
        # Seconds since epoch of the last failure
        self.lastFailure = 0

    def usage(self):
        print "%s: [-h] [-f config file] [-p pid file]" % sys.argv[0]
        print "    -h             Print usage (this message)"
        print "    -d             Debug mode. Will not fork."
        print "    -f <config>    Use configuration file"
        print "    -p <pidfile>   Write daemon pid to file"

    def restartDaemon(self):
        # Connect to LDAP and allocate our daemon context
        d = self.start()

        # We don't want the errback to fire immediately, as there's a potential
        # for infinite recursion
        reactor.callLater(0, d.addCallbacks, self._cbStart, self._ebStart)

    def _cbStart(self, result):
        """
        Daemon context was stopped successfully
        """
        self.logger.info("Shutting down")
        reactor.stop()

    def _ebStart(self, failure):
        """
        Handle daemon context failures
        """

       # Re-raise the exception
        try:
            failure.raiseException()
        except ldap.INVALID_CREDENTIALS, e:
            self.logger.critical("Authentication failed for server: %s", self.config.LDAP.uri)

        except ldap.NO_SUCH_OBJECT, e:
            self.logger.critical("Authentication failed for server: %s, no such object. Is your Bind DN correct?", self.config.LDAP.uri)

        except ldap.SERVER_DOWN, e:
            self.logger.critical("Could not contact server: %s", self.config.LDAP.uri)

        except FatalError, e:
            self.logger.critical("An unrecoverable error occured: %s", e)
            # Unrecoverable, stop the reactor
            reactor.stop()
            return

        except Exception, e:
            self.logger.critical("An unhandled error occured: %s", e)

        # Default action: Restart the daemon context
        #
        # If current time - last failure > self.maxDelay, reset the current delay
        # Otherwise, set the delay to MIN(ABS(normalvariate(self.delay * 2)), self.maxDelay)
        currentTime = time.time()
        if (currentTime - self.lastFailure > self.maxDelay):
            self.delay = self.initialDelay
        else:
            self.delay = self.delay * 2
        self.lastFailure = currentTime

        self.delay = min(abs(random.normalvariate(self.delay, self.jitter)), self.maxDelay)

        self.logger.info("Will attempt to reconnect in %d seconds" % int(self.delay))
        reactor.callLater(self.delay, self.restartDaemon)

    def main(self):
        conf_file = None
        pid_file = None
        debug_flag = False
    
        try:
            opts,args = getopt.getopt(sys.argv[1:], "hvdf:p:")
        except getopt.GetoptError:
            self.usage()
            sys.exit(2)

        for opt,arg in opts:
            if opt == "-h":
                self.usage()
                sys.exit()
            if opt == "-d":
                debug_flag = True
            if opt == "-f":
                conf_file = arg
            if opt == "-p":
                pid_file = arg

        if (conf_file == None):
            self.usage()
            sys.exit(1)

        # Close all file descriptors before doing anything else
        if (not debug_flag):
            self.close_fds()

        # Load our configuration schema
        schema = ZConfig.loadSchema(splat.CONFIG_SCHEMA)
        try:
            self.config, handler = ZConfig.loadConfig(schema, conf_file)
        except ZConfig.ConfigurationError, e:
            print "Configuration Error: %s" % e
            sys.exit(1)

        # Set up logging
        try:
            self.config.Logging()
        except Exception, e:
            print "Log initialization failed: %s" % e
            sys.exit(1)

        # Daemonize
        if (not debug_flag):
            self.daemonize(config, pid_file)

        # Acquire our logger
        self.logger = logging.getLogger(splat.LOG_NAME)

        # Connect to LDAP and allocate our daemon context
        d = self.start()

        # We don't want these to fire until the reactor is running
        reactor.callWhenRunning(d.addCallbacks, self._cbStart, self._ebStart)

        # Fire up the reactor
        reactor.run()

    def start(self):
        """
        Allocate a daemon context, populate it from our configuration,
        and add it to the runloop
        """
        d = defer.Deferred()

        # Connect to our LDAP server
        self.logger.info("Connecting to %s" % self.config.LDAP.uri)
        try:
            conn = ldaputils.Connection(self.config.LDAP.uri)
            conn.simple_bind(self.config.LDAP.binddn, self.config.LDAP.password)
        except ldap.LDAPError, e:
            d.errback(e)
            return d

        # Allocate and configure our daemon context
        ctx = daemon.Context(conn)

        # Load all service helpers
        for service in self.config.Service:
            options = {}

            # Set up service options
            for opt in service.Option:
                options[opt.getSectionName()] = opt.value

            try:
                # Use the default basedn if necessary
                if (service.searchbase == None):
                    basedn = self.config.LDAP.basedn
                else:
                    basedn = service.searchbase
                hc = plugin.HelperController(service.getSectionName(), service.helper, service.frequency, basedn,
                        service.searchfilter, service.requiregroup, options)

                # Find all per-service groups, if any
                for group in service.Group:

                    # Use the default basedn if necessary
                    if (group.searchbase == None):
                        basedn = self.config.LDAP.basedn
                    else:
                        basedn = group.searchbase

                    # Instantiate our group filter
                    if (group.memberattribute):
                        groupFilter = ldaputils.GroupFilter(basedn, ldap.SCOPE_SUBTREE, group.searchfilter, group.memberattribute)
                    else:
                        groupFilter = ldaputils.GroupFilter(basedn, ldap.SCOPE_SUBTREE, group.searchfilter)

                    # Load group-specific helper options
                    groupOptions = {}
                    groupOptions.update(options)

                    for opt in group.Option:
                        if (opt.value == None):
                            # Remove deleted options
                            groupOptions.pop(opt.getSectionName())
                        else:
                            # Add additional group options
                            groupOptions[opt.getSectionName()] = opt.value

                    # Add our newly minted group to the controller
                    hc.addGroup(groupFilter, groupOptions)

            except plugin.SplatPluginError, e:
                # This is a fatal error
                d.errback(FatalError("Error initializing service '%s': %s", service.getSectionName(), e))
                return d

            ctx.addHelper(hc)

        # Add our daemon context to the runloop
        ctxDefer = ctx.start()
        ctxDefer.chainDeferred(d)
        return d

    def close_fds(self):
        """ Close all FDs other than stdin, stdout, and stderr """
        # Close all open files.
        try:
            maxfd = os.sysconf("SC_OPEN_MAX")
        except (AttributeError, ValueError):
            maxfd = 1024       # default maximum

        # Avoid closing stdout/stdin/stderr so we can still print a log
        # initialization error message ...
        stdin = sys.stdin.fileno()
        stdout = sys.stdout.fileno()
        stderr = sys.stderr.fileno()

        for fd in range(0, maxfd):
            try:
                if (fd != stdout and fd != stderr and fd != stdin):
                    os.close(fd)
            except OSError:
                # Ignore EBADF
                pass

    def daemonize(self, config, pid_file):
        """ Detach a process from the terminal and run it as a daemon """
        # Redirect stdin, stdout, and stderr to /dev/null
        null = os.open('/dev/null', os.O_RDWR)
        os.dup2(null, sys.stdin.fileno())
        os.dup2(null, sys.stdout.fileno())
        os.dup2(null, sys.stderr.fileno())

        if (null > 2):
            os.close(null)

        self.logger.info("splatd starting up...")

        # Detach the daemon
        try:
            pid = os.fork()
        except OSError, e:
            self.logger.critical("An OSError occurred in daemonize(): %s", e)
            sys.exit(1)

        if pid == 0:
            # become the session leader
            os.setsid()

            # ignore SIGHUP, since children are sent SIGHUP when the parent
            # terminates
            signal.signal(signal.SIGHUP, signal.SIG_IGN)

            try:
                # second child to prevent zombies
                pid = os.fork()
            except OSError, e:
                self.logger.critical("An OSError occurred in daemonize(): %s", e)
                sys.exit(1)

            if pid == 0:
                # Write out our pid file
                if (pid_file):
                    f = open(pid_file, 'w')
                    f.write("%d\n" % os.getpid())
                    f.close()

                # Make sure we don't keep any directory in use
                os.chdir("/")
                os.umask(022)
            else:
                os._exit(0)
        else:
            os._exit(0)

        return 0

if __name__ == "__main__":
    main = Main()
    main.main()
